package ui

import (
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"strconv"
	"time"
	"sort"
	"runtime"
	"os/exec"
	"errors"
	"regexp"
	"net/http"
	"context"
	"downloadmanager/assets"
	"downloadmanager/internal/config"
	"downloadmanager/internal/i18n"
	"downloadmanager/internal/manager"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/driver/desktop"
	"fyne.io/fyne/v2/dialog"
	"fyne.io/fyne/v2/theme"
	"fyne.io/fyne/v2/widget"
	"github.com/anacrolix/torrent/metainfo"
)

// rowBox wraps a row content and captures right-click to show context.
type rowBox struct {
	widget.BaseWidget
	content fyne.CanvasObject
	onRight func(pos fyne.Position)
	onTap   func()
}

func (mw *mainWindow) openSettings() {
    langs := []string{"zh-CN", "en-US"}
    sel := widget.NewSelect(langs, nil)
    cur := strings.TrimSpace(mw.cfg.Language)
    if cur == "" { cur = "zh-CN" }
    sel.SetSelected(cur)
    // downloader parameters
    entChunks := widget.NewEntry()
    entChunks.SetText(fmt.Sprintf("%d", mw.cfg.ChunkCount))
    entRetries := widget.NewEntry()
    entRetries.SetText(fmt.Sprintf("%d", mw.cfg.MaxRetries))
    entBackoff := widget.NewEntry()
    entBackoff.SetText(fmt.Sprintf("%d", mw.cfg.RetryBackoffSec))
    // BitTorrent settings
    chkBTNoUpload := widget.NewCheck("BT 禁止上传 (NoUpload)", nil)
    chkBTNoUpload.SetChecked(mw.cfg.BTNoUpload)
    entBTListen := widget.NewEntry()
    entBTListen.SetText(fmt.Sprintf("%d", mw.cfg.BTListenPort))
    // rules/group limits JSON editors
    txtRules := widget.NewMultiLineEntry()
    if b, err := json.MarshalIndent(mw.cfg.Rules, "", "  "); err == nil { txtRules.SetText(string(b)) }
    txtGroups := widget.NewMultiLineEntry()
    if b, err := json.MarshalIndent(mw.cfg.GroupLimits, "", "  "); err == nil { txtGroups.SetText(string(b)) }

    form := container.NewVBox(
        widget.NewForm(
            widget.NewFormItem(i18n.T("field.language"), sel),
            widget.NewFormItem(i18n.T("field.chunks"), entChunks),
            widget.NewFormItem(i18n.T("field.max_retries"), entRetries),
            widget.NewFormItem(i18n.T("field.retry_backoff"), entBackoff),
            widget.NewFormItem("BT NoUpload", chkBTNoUpload),
            widget.NewFormItem("BT Listen Port", entBTListen),
            widget.NewFormItem(i18n.T("settings.rules_json"), txtRules),
            widget.NewFormItem(i18n.T("settings.group_limits_json"), txtGroups),
        ),
        widget.NewLabel(i18n.T("settings.hint.restart")),
    )
    dialog.NewCustomConfirm(i18n.T("settings.title"), i18n.T("settings.apply"), i18n.T("settings.cancel"), form, func(ok bool) {
        if !ok { return }
        chosen := strings.TrimSpace(sel.Selected)
        if chosen == "" { chosen = "zh-CN" }
        // parse ints
        parseInt := func(s string) (int, error) {
            s = strings.TrimSpace(s)
            if s == "" { return 0, fmt.Errorf(i18n.T("error.int.invalid")) }
            v, err := strconv.Atoi(s)
            if err != nil { return 0, fmt.Errorf(i18n.T("error.int.invalid")) }
            return v, nil
        }
        chunks, err1 := parseInt(entChunks.Text)
        retries, err2 := parseInt(entRetries.Text)
        backoff, err3 := parseInt(entBackoff.Text)
        btListen, err4 := func(s string) (int, error) {
            s = strings.TrimSpace(s)
            if s == "" { return 0, nil }
            v, err := strconv.Atoi(s)
            if err != nil { return 0, fmt.Errorf(i18n.T("error.int.invalid")) }
            if v < 0 { return 0, fmt.Errorf(i18n.T("error.int.nonneg")) }
            return v, nil
        }(entBTListen.Text)
        if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
            dialog.ShowError(fmt.Errorf(i18n.T("error.int.invalid")), mw.win)
            return
        }
        if chunks < 1 {
            dialog.ShowError(fmt.Errorf(i18n.T("error.chunks.min1")), mw.win)
            return
        }
        if retries < 0 || backoff < 0 {
            dialog.ShowError(fmt.Errorf(i18n.T("error.int.nonneg")), mw.win)
            return
        }
        newCfg := *mw.cfg
        newCfg.Language = chosen
        newCfg.ChunkCount = chunks
        newCfg.MaxRetries = retries
        newCfg.RetryBackoffSec = backoff
        newCfg.BTNoUpload = chkBTNoUpload.Checked
        newCfg.BTListenPort = btListen
        // parse rules JSON
        var rules []config.Rule
        if s := strings.TrimSpace(txtRules.Text); s != "" {
            if err := json.Unmarshal([]byte(s), &rules); err != nil {
                dialog.ShowError(fmt.Errorf(i18n.T("error.json.invalid")), mw.win)
                return
            }
            newCfg.Rules = rules
        } else { newCfg.Rules = []config.Rule{} }
        // parse group limits JSON
        var gl map[string]int
        if s := strings.TrimSpace(txtGroups.Text); s != "" {
            if err := json.Unmarshal([]byte(s), &gl); err != nil {
                dialog.ShowError(fmt.Errorf(i18n.T("error.json.invalid")), mw.win)
                return
            }
            newCfg.GroupLimits = gl
        } else { newCfg.GroupLimits = map[string]int{} }
        mw.cfg = &newCfg
        mw.mgr.UpdateSettings(&newCfg)
        _ = config.Save(&newCfg)
        i18n.SetLanguage(newCfg.Language)
        // apply some visible text immediately
        mw.win.SetTitle(i18n.T("app.title"))
        mw.updateHeader()
        mw.setupSystemTray(mw.app)
    }, mw.win).Show()
}

// categorizeLabel maps filename to a human-friendly category (与后端分类一致)
func categorizeLabel(name string) string {
    ext := strings.ToLower(filepath.Ext(name))
    switch ext {
    case ".mp4", ".mkv", ".mov", ".avi", ".flv", ".wmv", ".webm":
        return i18n.T("category.video")
    case ".mp3", ".flac", ".aac", ".wav", ".ogg", ".m4a":
        return i18n.T("category.music")
    case ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz":
        return i18n.T("category.archive")
    case ".iso", ".img":
        return i18n.T("category.image_iso")
    case ".exe", ".msi", ".apk", ".dmg", ".pkg", ".deb", ".rpm":
        return i18n.T("category.program")
    case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt":
        return i18n.T("category.document")
    case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg":
        return i18n.T("category.image")
    default:
        return ""
    }
}

func (mw *mainWindow) promptQuickAdd(u string) {
    entryURL := widget.NewEntry()
    entryURL.SetText(u)
    entryName := widget.NewEntry()
    entryName.SetText(filepath.Base(u))
    prSelect := widget.NewSelect([]string{i18n.T("priority.high"), i18n.T("priority.medium"), i18n.T("priority.low")}, nil)
    prSelect.SetSelected(i18n.T("priority.medium"))
    d := dialog.NewForm(i18n.T("action.quick_add.title"), i18n.T("action.quick_add.ok"), i18n.T("action.cancel"),
        []*widget.FormItem{
            widget.NewFormItem(i18n.T("field.url"), entryURL),
            widget.NewFormItem(i18n.T("field.filename"), entryName),
            widget.NewFormItem(i18n.T("field.priority"), prSelect),
        }, func(ok bool){
            if !ok { return }
            url := strings.TrimSpace(entryURL.Text)
            if url == "" { return }
            name := strings.TrimSpace(entryName.Text)
            if name == "" { name = filepath.Base(url) }
            var pr manager.Priority
            switch prSelect.Selected {
            case i18n.T("priority.high"): pr = manager.PriorityHigh
            case i18n.T("priority.low"): pr = manager.PriorityLow
            default: pr = manager.PriorityMedium
            }
            _ = mw.mgr.Start(mw.mgr.AddWithPriority(url, name, pr).ID, func(task *manager.Task) { mw.list.Refresh() })
        }, mw.win)
    d.Show()
}

func (mw *mainWindow) addTask() {
    entryURL := widget.NewEntry()
    entryURL.SetPlaceHolder(i18n.T("add.url.placeholder"))
    btnPickTorrent := widget.NewButton("选择 .torrent", func() {
        dialog.NewFileOpen(func(ur fyne.URIReadCloser, err error) {
            if err != nil || ur == nil { return }
            defer ur.Close()
            if u := ur.URI(); u != nil {
                // prefer local file path for .torrent
                p := strings.TrimSpace(u.Path())
                if p != "" { entryURL.SetText(p); return }
                entryURL.SetText(u.String())
            }
        }, mw.win).Show()
    })
    entryName := widget.NewEntry()
    entryName.SetPlaceHolder(i18n.T("add.filename.placeholder"))
    entryMirrors := widget.NewMultiLineEntry()
    entryMirrors.SetPlaceHolder(i18n.T("add.sources.placeholder"))
    selAlgo := widget.NewSelect([]string{"", "MD5", "SHA256"}, nil)
    selAlgo.PlaceHolder = i18n.T("field.checksum_algo")
    entChecksum := widget.NewEntry()
    entChecksum.SetPlaceHolder(i18n.T("add.checksum.placeholder"))
    prSelect := widget.NewSelect([]string{i18n.T("priority.high"), i18n.T("priority.medium"), i18n.T("priority.low")}, nil)
    prSelect.SetSelected(i18n.T("priority.medium"))

    d := dialog.NewForm(i18n.T("add.title"), i18n.T("action.ok"), i18n.T("action.cancel"),
        []*widget.FormItem{
            widget.NewFormItem(i18n.T("field.url"), container.NewHBox(entryURL, btnPickTorrent)),
            widget.NewFormItem(i18n.T("field.filename"), entryName),
            widget.NewFormItem(i18n.T("field.mirrors"), entryMirrors),
            widget.NewFormItem(i18n.T("field.checksum_algo"), selAlgo),
            widget.NewFormItem(i18n.T("field.checksum_value"), entChecksum),
            widget.NewFormItem(i18n.T("field.priority"), prSelect),
        }, func(ok bool) {
            if !ok { return }
            url := strings.TrimSpace(entryURL.Text)
            if url == "" { return }
            name := strings.TrimSpace(entryName.Text)
            if name == "" { name = filepath.Base(url) }
            // Optional: pre-pick files for local .torrent
            var preSelected []int
            if strings.HasSuffix(strings.ToLower(url), ".torrent") {
                if fi, err := os.Stat(url); err == nil && !fi.IsDir() {
                    if mi, e := metainfo.LoadFromFile(url); e == nil {
                        if info, e2 := mi.UnmarshalInfo(); e2 == nil {
                            // build checklist dialog
                            files := info.Files
                            if len(files) > 0 {
                                checks := make([]*widget.Check, 0, len(files))
                                for i := range files {
                                    p := filepath.Join(files[i].Path...)
                                    chk := widget.NewCheck(p, nil)
                                    chk.SetChecked(true)
                                    checks = append(checks, chk)
                                }
                                cont := container.NewVScroll(container.NewVBox())
                                for _, c := range checks { cont.Content.(*fyne.Container).Add(c) }
                                done := make(chan bool, 1)
                                dialog.NewCustomConfirm("选择要下载的文件", i18n.T("action.ok"), i18n.T("action.cancel"), cont, func(ok bool){
                                    if ok {
                                        idxs := make([]int, 0, len(checks))
                                        for i, c := range checks { if c.Checked { idxs = append(idxs, i) } }
                                        preSelected = idxs
                                    }
                                    done <- true
                                }, mw.win).Show()
                                <-done
                            }
                        }
                    }
                }
            }
            // parse mirrors
            mirrors := []string{}
            if ms := strings.TrimSpace(entryMirrors.Text); ms != "" {
                for _, line := range strings.Split(ms, "\n") {
                    u := strings.TrimSpace(line)
                    if u != "" { mirrors = append(mirrors, u) }
                }
            }
            var pr manager.Priority
            switch prSelect.Selected {
            case i18n.T("priority.high"): pr = manager.PriorityHigh
            case i18n.T("priority.low"): pr = manager.PriorityLow
            default: pr = manager.PriorityMedium
            }
            var task *manager.Task
            if len(mirrors) > 0 {
                // primary first argument, mirrors passed via manager AddWithSources
                urls := append([]string{url}, mirrors...)
                task = mw.mgr.AddWithSources(urls, name, pr)
            } else {
                task = mw.mgr.AddWithPriority(url, name, pr)
            }
            if len(preSelected) > 0 { _ = mw.mgr.SetBTSelectedFiles(task.ID, preSelected) }
            // checksum validation and setting
            algo := strings.ToUpper(strings.TrimSpace(selAlgo.Selected))
            chk := strings.TrimSpace(entChecksum.Text)
            if algo != "" {
                if chk == "" {
                    dialog.ShowError(fmt.Errorf(i18n.T("error.checksum.format")), mw.win)
                    return
                }
                // basic hex validation and length check per algo
                isHex := regexp.MustCompile(`^[0-9a-fA-F]+$`).MatchString
                if !isHex(chk) {
                    dialog.ShowError(fmt.Errorf(i18n.T("error.checksum.format")), mw.win)
                    return
                }
                if (algo == "MD5" && len(chk) != 32) || (algo == "SHA256" && len(chk) != 64) {
                    dialog.ShowError(fmt.Errorf(i18n.T("error.checksum.format")), mw.win)
                    return
                }
                _ = mw.mgr.SetChecksum(task.ID, algo, chk)
            }
            _ = mw.mgr.Start(task.ID, func(task *manager.Task) { mw.list.Refresh() })
        }, mw.win)
    d.Show()
}

func isBTURL(u string) bool {
    lu := strings.ToLower(strings.TrimSpace(u))
    if lu == "" { return false }
    if strings.HasPrefix(lu, "magnet:") { return true }
    if strings.HasSuffix(lu, ".torrent") { return true }
    return false
}

func (mw *mainWindow) addBatch() {
    entry := widget.NewMultiLineEntry()
    entry.SetPlaceHolder(i18n.T("batch.url.placeholder"))
    prSelect := widget.NewSelect([]string{i18n.T("priority.high"), i18n.T("priority.medium"), i18n.T("priority.low")}, nil)
    prSelect.SetSelected(i18n.T("priority.medium"))
    form := dialog.NewForm(i18n.T("batch.title"), i18n.T("batch.add"), i18n.T("batch.cancel"),
        []*widget.FormItem{
            widget.NewFormItem(i18n.T("field.url_list"), entry),
            widget.NewFormItem(i18n.T("field.priority"), prSelect),
        }, func(ok bool) {
            if !ok { return }
            lines := strings.Split(entry.Text, "\n")
            var pr manager.Priority
            switch prSelect.Selected {
            case i18n.T("priority.high"): pr = manager.PriorityHigh
            case i18n.T("priority.low"): pr = manager.PriorityLow
            default: pr = manager.PriorityMedium
            }
            for _, line := range lines {
                raw := strings.TrimSpace(line)
                if raw == "" { continue }
                // support multi-source via '|' separator: primary|mirror1|mirror2
                parts := strings.Split(raw, "|")
                urls := make([]string, 0, len(parts))
                for _, p := range parts {
                    u := strings.TrimSpace(p)
                    if u != "" { urls = append(urls, u) }
                }
                if len(urls) == 0 { continue }
                name := filepath.Base(urls[0])
                if len(urls) > 1 {
                    mw.mgr.AddWithSources(urls, name, pr)
                } else {
                    mw.mgr.AddWithPriority(urls[0], name, pr)
                }
            }
            mw.list.Refresh()
        }, mw.win)
    form.Resize(fyne.NewSize(600, 400))
    form.Show()
}

func (mw *mainWindow) openSelectedFile() {
    id := mw.selected
    if id < 0 || id >= len(mw.view) { return }
    mw.openFilePath(mw.view[id].SavePath)
}

// applyView filters and sorts tasks into view
func (mw *mainWindow) applyView() {
    // filter
    v := make([]*manager.Task, 0, len(mw.tasks))
    text := strings.ToLower(strings.TrimSpace(mw.filterText))
    for _, t := range mw.tasks {
        if mw.filterStatus != nil && t.Status != *mw.filterStatus { continue }
        if mw.filterCat != "" {
            if categorizeLabel(filepath.Base(t.SavePath)) != mw.filterCat { continue }
        }
        if text != "" {
            name := strings.ToLower(filepath.Base(t.SavePath))
            url := strings.ToLower(t.URL)
            if !strings.Contains(name, text) && !strings.Contains(url, text) { continue }
        }
        v = append(v, t)
    }
    // sort
    switch mw.sortKey {
    case "priority":
        sort.Slice(v, func(i, j int) bool { if mw.sortAsc { return v[i].Priority < v[j].Priority } ; return v[i].Priority > v[j].Priority })
    case "name":
        sort.Slice(v, func(i, j int) bool { if mw.sortAsc { return filepath.Base(v[i].SavePath) < filepath.Base(v[j].SavePath) } ; return filepath.Base(v[i].SavePath) > filepath.Base(v[j].SavePath) })
    case "status":
        sort.Slice(v, func(i, j int) bool { if mw.sortAsc { return v[i].Status < v[j].Status } ; return v[i].Status > v[j].Status })
    case "progress":
        prog := func(t *manager.Task) float64 { if t.Progress.Total > 0 { return float64(t.Progress.Completed) / float64(t.Progress.Total) } ; return 0 }
        sort.Slice(v, func(i, j int) bool { if mw.sortAsc { return prog(v[i]) < prog(v[j]) } ; return prog(v[i]) > prog(v[j]) })
    case "speed":
        sp := func(t *manager.Task) int64 { return t.Progress.SpeedBps }
        sort.Slice(v, func(i, j int) bool { if mw.sortAsc { return sp(v[i]) < sp(v[j]) } ; return sp(v[i]) > sp(v[j]) })
    default: // created
        sort.Slice(v, func(i, j int) bool { if mw.sortAsc { return v[i].CreatedAt.Before(v[j].CreatedAt) } ; return v[i].CreatedAt.After(v[j].CreatedAt) })
    }
    mw.view = v
    // fix selection if out of range
    if mw.selected >= len(mw.view) { mw.selected = -1 }
}

func (mw *mainWindow) toggleSort(key string) {
    if mw.sortKey == key {
        mw.sortAsc = !mw.sortAsc
    } else {
        mw.sortKey = key
        // default asc for most keys; for created default to desc handled elsewhere
        mw.sortAsc = true
    }
    mw.applyView()
    mw.updateHeader()
    if mw.list != nil { mw.list.Refresh() }
}

func (mw *mainWindow) updateHeader() {
    if mw.hdrName == nil { return }
    arrow := func(key string) string {
        if mw.sortKey != key { return "" }
        if mw.sortAsc { return " ↑" }
        return " ↓"
    }
    mw.hdrName.SetText(i18n.T("header.name") + arrow("name"))
    mw.hdrStatus.SetText(i18n.T("header.status") + arrow("status"))
    mw.hdrProg.SetText(i18n.T("header.progress") + arrow("progress"))
    mw.hdrSpeed.SetText(i18n.T("header.speed") + arrow("speed"))
    mw.hdrPrio.SetText(i18n.T("header.priority") + arrow("priority"))
    mw.hdrTime.SetText(i18n.T("header.time") + arrow("created"))
}

func (mw *mainWindow) openFilePath(file string) {
    switch runtime.GOOS {
    case "windows":
        _ = exec.Command("cmd", "/C", "start", "", file).Start()
    case "darwin":
        _ = exec.Command("open", file).Start()
    default:
        _ = exec.Command("xdg-open", file).Start()
    }
}

func (mw *mainWindow) openDirPath(dir string) {
    switch runtime.GOOS {
    case "windows":
        _ = exec.Command("explorer", dir).Start()
    case "darwin":
        _ = exec.Command("open", dir).Start()
    default:
        _ = exec.Command("xdg-open", dir).Start()
    }
}

func NewMainWindow(a fyne.App, mgr *manager.Manager, cfg *config.Settings) fyne.Window {
    w := a.NewWindow(i18n.T("app.title"))
    mw := &mainWindow{app: a, win: w, mgr: mgr, cfg: cfg}
    mw.build()
    mw.setupSystemTray(a)
    return w
}

func (mw *mainWindow) setupSystemTray(a fyne.App) {
    // Set an application icon (custom)
    a.SetIcon(assets.AppIcon)
    if desk, ok := a.(desktop.App); ok {
        desk.SetSystemTrayIcon(assets.AppIcon)
        status := fyne.NewMenuItem(i18n.T("tray.status.ready"), nil)
        tray := fyne.NewMenu(i18n.T("tray.title"),
            status,
            fyne.NewMenuItemSeparator(),
            fyne.NewMenuItem(i18n.T("tray.open_window"), func() {
                mw.win.Show()
                mw.win.RequestFocus()
            }),
            fyne.NewMenuItem(i18n.T("tray.quit"), func() { a.Quit() }),
        )
        desk.SetSystemTrayMenu(tray)
    }
}

func (mw *mainWindow) updateTray(running, waiting, completed int, spMB float64) {
    if desk, ok := mw.app.(desktop.App); ok {
        // build speed presets submenu
        currentMB := float64(mw.cfg.SpeedLimitBytes) / 1024.0 / 1024.0
        mk := func(label string, mb int) *fyne.MenuItem {
            show := label
            if (mb == 0 && mw.cfg.SpeedLimitBytes == 0) || (mb > 0 && int(currentMB+0.5) == mb) {
                show = "✓ " + label
            }
            return fyne.NewMenuItem(show, func() { mw.applySpeedPreset(mb) })
        }
        speedMenu := fyne.NewMenu(i18n.T("tray.speed"),
            mk(i18n.T("status.speed.unlimited"), 0),
            mk("1 MB/s", 1),
            mk("2 MB/s", 2),
            mk("5 MB/s", 5),
            mk("10 MB/s", 10),
        )
        // build batch submenu
        batchMenu := fyne.NewMenu(i18n.T("tray.batch"),
            fyne.NewMenuItem(i18n.T("tray.batch.start_all"), func() { mw.startAll() }),
            fyne.NewMenuItem(i18n.T("tray.batch.pause_all"), func() { mw.pauseAll() }),
            fyne.NewMenuItemSeparator(),
            fyne.NewMenuItem(i18n.T("tray.clear_completed"), func() { mw.clearCompleted() }),
        )
        // auto shutdown toggle & cancel
        autoLabel := i18n.T("tray.autoshutdown.off")
        if mw.cfg.AutoShutdown { autoLabel = i18n.T("tray.autoshutdown.on") }
        autoToggle := fyne.NewMenuItem(autoLabel, func(){
            newCfg := *mw.cfg
            newCfg.AutoShutdown = !mw.cfg.AutoShutdown
            mw.cfg = &newCfg
            _ = config.Save(&newCfg)
        })
        cancelShutdown := fyne.NewMenuItem(i18n.T("tray.cancel_shutdown"), func(){ mw.cancelShutdown() })
        cancelShutdown.Disabled = !mw.shutdownArmed

        clipLabel := i18n.T("tray.clip.off")
        if mw.clipWatch { clipLabel = i18n.T("tray.clip.on") }
        clipToggle := fyne.NewMenuItem(clipLabel, func(){ mw.clipWatch = !mw.clipWatch })

        apiLabel := i18n.T("tray.api.off")
        if mw.apiRunning { apiLabel = i18n.T("tray.api.on") }
        apiToggle := fyne.NewMenuItem(apiLabel, func(){
            if mw.apiRunning { mw.stopHTTPAPI() } else { _ = mw.startHTTPAPI() }
        })

        tray := fyne.NewMenu(i18n.T("tray.title"),
            fyne.NewMenuItem(
                fmt.Sprintf(i18n.T("tray.status.fmt"), running, waiting, completed, spMB),
                nil,
            ),
            fyne.NewMenuItemSeparator(),
            fyne.NewMenuItem(i18n.T("tray.open_window"), func() {
                mw.win.Show(); mw.win.RequestFocus()
            }),
            fyne.NewMenuItem(i18n.T("tray.quit"), func() { mw.app.Quit() }),
        )
        // insert speed submenu before Quit
        tray.Items = append(tray.Items[:len(tray.Items)-1], fyne.NewMenuItem(i18n.T("tray.speed"), nil))
        tray.Items[len(tray.Items)-1].ChildMenu = speedMenu
        // insert batch submenu as well
        tray.Items = append(tray.Items, fyne.NewMenuItem(i18n.T("tray.batch"), nil))
        tray.Items[len(tray.Items)-1].ChildMenu = batchMenu
        // auto shutdown controls
        tray.Items = append(tray.Items, autoToggle)
        tray.Items = append(tray.Items, cancelShutdown)
        tray.Items = append(tray.Items, clipToggle)
        tray.Items = append(tray.Items, apiToggle)
        tray.Items = append(tray.Items, fyne.NewMenuItem(i18n.T("tray.quit"), func() { mw.app.Quit() }))
        desk.SetSystemTrayMenu(tray)
    }
}

func (mw *mainWindow) startHTTPAPI() error {
    if mw.apiRunning { return nil }
    mux := http.NewServeMux()
    mux.HandleFunc("/add", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        // token check
        if tok := mw.cfg.HttpAPIToken; tok != "" {
            if r.URL.Query().Get("token") != tok { w.WriteHeader(http.StatusUnauthorized); _ = json.NewEncoder(w).Encode(map[string]any{"ok":false,"error":"unauthorized"}); return }
        }
        url := strings.TrimSpace(r.URL.Query().Get("url"))
        if url == "" { w.WriteHeader(http.StatusBadRequest); _ = json.NewEncoder(w).Encode(map[string]any{"ok":false,"error":"missing url"}); return }
        name := strings.TrimSpace(r.URL.Query().Get("name"))
        if name == "" { name = filepath.Base(url) }
        var pr manager.Priority = manager.PriorityMedium
        switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("pr"))) {
        case "high": pr = manager.PriorityHigh
        case "low": pr = manager.PriorityLow
        }
        t := mw.mgr.AddWithPriority(url, name, pr)
        _ = mw.mgr.Start(t.ID, func(*manager.Task){ mw.list.Refresh() })
        _ = json.NewEncoder(w).Encode(map[string]any{"ok":true})
    })
    addr := fmt.Sprintf("127.0.0.1:%d", mw.cfg.HttpAPIPort)
    mw.apiSrv = &http.Server{ Addr: addr, Handler: mux }
    mw.apiRunning = true
    go func(){ _ = mw.apiSrv.ListenAndServe() }()
    return nil
}

func (mw *mainWindow) stopHTTPAPI() {
    if !mw.apiRunning { return }
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    if mw.apiSrv != nil { _ = mw.apiSrv.Shutdown(ctx) }
    mw.apiRunning = false
}

func (mw *mainWindow) armShutdown(delayMin int) {
    if mw.shutdownArmed { return }
    if delayMin < 0 { delayMin = 0 }
    d := time.Duration(delayMin) * time.Minute
    mw.shutdownArmed = true
    mw.shutdownDueAt = time.Now().Add(d)
    if mw.shutdownTimer != nil { mw.shutdownTimer.Stop() }
    mw.shutdownTimer = time.AfterFunc(d, func(){ mw.performShutdown() })
    fyne.CurrentApp().SendNotification(&fyne.Notification{ Title: i18n.T("notify.shutdown.armed.title"), Content: i18n.TF("notify.shutdown.armed.content", delayMin) })
}

func (mw *mainWindow) cancelShutdown() {
    if mw.shutdownTimer != nil { mw.shutdownTimer.Stop() }
    if mw.shutdownArmed {
        mw.shutdownArmed = false
        mw.shutdownDueAt = time.Time{}
        fyne.CurrentApp().SendNotification(&fyne.Notification{ Title: i18n.T("notify.shutdown.canceled.title"), Content: i18n.T("notify.shutdown.canceled.content") })
    }
}

func (mw *mainWindow) performShutdown() {
    // best-effort shutdown per OS
    go func(){
        switch runtime.GOOS {
        case "windows":
            _ = exec.Command("shutdown", "/s", "/t", "0").Start()
        case "darwin":
            _ = exec.Command("osascript", "-e", "tell app \"System Events\" to shut down").Start()
        default:
            _ = exec.Command("shutdown", "-h", "now").Start()
        }
    }()
}

func (mw *mainWindow) startAll() {
    for _, t := range mw.mgr.List() {
        if t.Status != manager.StatusCompleted {
            _ = mw.mgr.Start(t.ID, func(*manager.Task) {})
        }
    }
    mw.tasks = mw.mgr.List()
    mw.list.Refresh()
}

func (mw *mainWindow) pauseAll() {
    for _, t := range mw.mgr.List() {
        if t.Status == manager.StatusRunning || t.Status == manager.StatusWaiting {
            _ = mw.mgr.Pause(t.ID)
        }
    }
    mw.tasks = mw.mgr.List()
    mw.list.Refresh()
}

func (mw *mainWindow) clearCompleted() {
    for _, t := range mw.mgr.List() {
        if t.Status == manager.StatusCompleted {
            _ = mw.mgr.Remove(t.ID)
        }
    }
    mw.tasks = mw.mgr.List()
    mw.list.Refresh()
}

func (mw *mainWindow) applySpeedPreset(mb int) {
    var bytes int64 = 0
    if mb > 0 {
        bytes = int64(mb) * 1024 * 1024
    }
    newCfg := *mw.cfg
    newCfg.SpeedLimitBytes = bytes
    mw.mgr.UpdateSettings(&newCfg)
    mw.cfg = &newCfg
    _ = config.Save(&newCfg)
    // also update status bar immediately
    mw.status.SetText(fmt.Sprintf(i18n.T("status.speed.limit"), func() string { if mb == 0 { return i18n.T("status.speed.unlimited") } ; return fmt.Sprintf("%d MB/s", mb) }()))
}

func (mw *mainWindow) openSpeedLimit() {
    entry := widget.NewEntry()
    if mw.cfg.SpeedLimitBytes > 0 {
        entry.SetText(fmt.Sprintf("%.2f", float64(mw.cfg.SpeedLimitBytes)/1024.0/1024.0))
    }
    form := dialog.NewForm(i18n.T("speed.title"), i18n.T("speed.apply"), i18n.T("speed.cancel"),
        []*widget.FormItem{widget.NewFormItem(i18n.T("speed.field.placeholder"), entry)},
        func(ok bool) {
            if !ok { return }
            s := strings.TrimSpace(entry.Text)
            var bytes int64 = 0
            if s != "" {
                f, err := strconv.ParseFloat(s, 64)
                if err != nil || f < 0 {
                    dialog.ShowError(errors.New(i18n.T("error.speed.invalid")), mw.win)
                    return
                }
                bytes = int64(f * 1024 * 1024)
            }
            newCfg := *mw.cfg
            newCfg.SpeedLimitBytes = bytes
            mw.mgr.UpdateSettings(&newCfg)
            mw.cfg = &newCfg
            if err := config.Save(&newCfg); err != nil {
                dialog.ShowError(fmt.Errorf(i18n.T("error.config.save"), err), mw.win)
            }
        }, mw.win)
    form.Resize(fyne.NewSize(420, 160))
    form.Show()
}

type simpleTask struct {
    URL      string              `json:"url"`
    Filename string              `json:"filename"`
    Priority manager.Priority    `json:"priority"`
    ChecksumAlgo  string         `json:"checksum_algo,omitempty"`
    ChecksumValue string         `json:"checksum_value,omitempty"`
    Sources  []string            `json:"sources,omitempty"`
}

func (mw *mainWindow) exportTasks() {
    // prepare data
    tasks := mw.mgr.List()
    out := make([]simpleTask, 0, len(tasks))
    for _, t := range tasks {
        if t.Status == manager.StatusCompleted { continue }
        out = append(out, simpleTask{
            URL: t.URL,
            Filename: filepath.Base(t.SavePath),
            Priority: t.Priority,
            ChecksumAlgo: strings.ToUpper(strings.TrimSpace(t.ChecksumAlgo)),
            ChecksumValue: strings.TrimSpace(t.ChecksumValue),
            Sources: append([]string{}, t.Sources...),
        })
    }
    b, err := json.MarshalIndent(out, "", "  ")
    if err != nil { dialog.ShowError(err, mw.win); return }
    dialog.NewFileSave(func(uc fyne.URIWriteCloser, err error) {
        if err != nil || uc == nil { return }
        defer uc.Close()
        if _, e := uc.Write(b); e != nil {
            dialog.ShowError(e, mw.win)
        }
    }, mw.win).Show()
}

func (mw *mainWindow) importTasks() {
    dialog.NewFileOpen(func(ur fyne.URIReadCloser, err error) {
        if err != nil || ur == nil { return }
        defer ur.Close()
        data, e := io.ReadAll(ur)
        if e != nil { dialog.ShowError(e, mw.win); return }
        var items []simpleTask
        if e := json.Unmarshal(data, &items); e != nil { dialog.ShowError(e, mw.win); return }
        for _, it := range items {
            name := strings.TrimSpace(it.Filename)
            if name == "" { name = filepath.Base(it.URL) }
            pr := it.Priority
            if pr < manager.PriorityHigh || pr > manager.PriorityLow { pr = manager.PriorityMedium }
            var created *manager.Task
            if len(it.Sources) > 0 {
                urls := append([]string{it.URL}, it.Sources...)
                created = mw.mgr.AddWithSources(urls, name, pr)
            } else {
                created = mw.mgr.AddWithPriority(it.URL, name, pr)
            }
            algo := strings.ToUpper(strings.TrimSpace(it.ChecksumAlgo))
            val := strings.TrimSpace(it.ChecksumValue)
            if algo != "" && val != "" {
                _ = mw.mgr.SetChecksum(created.ID, algo, val)
            }
        }
        mw.tasks = mw.mgr.List()
        mw.list.Refresh()
    }, mw.win).Show()
}

func (mw *mainWindow) changePriorityDelta(idx int, delta int) {
    if idx < 0 || idx >= len(mw.tasks) { return }
    t := mw.tasks[idx]
    // Priority enum: High(0), Medium(1), Low(2)
    cur := int(t.Priority)
    nxt := cur + delta
    if nxt < int(manager.PriorityHigh) { nxt = int(manager.PriorityHigh) }
    if nxt > int(manager.PriorityLow) { nxt = int(manager.PriorityLow) }
    if nxt == cur { return }
    _ = mw.mgr.UpdatePriority(t.ID, manager.Priority(nxt))
    mw.tasks = mw.mgr.List()
    mw.list.Refresh()
}

func newRowBox(content fyne.CanvasObject) *rowBox {
    r := &rowBox{content: content}
    r.ExtendBaseWidget(r)
    return r
}

func (r *rowBox) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(r.content) }
func (r *rowBox) Tapped(*fyne.PointEvent)              { if r.onTap != nil { r.onTap() } }
func (r *rowBox) TappedSecondary(pe *fyne.PointEvent)  { if r.onRight != nil { r.onRight(pe.Position) } }

func priorityLabel(pr manager.Priority) string {
    switch pr {
    case manager.PriorityHigh:
        return "[" + i18n.T("priority.high") + "]"
    case manager.PriorityLow:
        return "[" + i18n.T("priority.low") + "]"
    default:
        return "[" + i18n.T("priority.medium") + "]"
    }
}

type mainWindow struct {
    win fyne.Window
    mgr *manager.Manager
    cfg *config.Settings
    app fyne.App

    list  *widget.List
    tasks []*manager.Task
    view  []*manager.Task
    status *widget.Label
    selected int
    lastStatus map[string]manager.Status
    // filters & sort
    filterText string
    filterStatus *manager.Status
    sortKey string // created|priority|name|status
    sortAsc bool

    // header buttons for sorting
    hdrName  *widget.Button
    hdrStatus *widget.Button
    hdrProg  *widget.Button
    hdrSpeed *widget.Button
    hdrPrio  *widget.Button
    hdrTime  *widget.Button

    // auto-shutdown state
    shutdownArmed bool
    shutdownTimer *time.Timer
    shutdownDueAt time.Time

    // clipboard monitor
    clipWatch bool
    lastClip string

    // http api
    apiRunning bool
    apiSrv *http.Server

    // category filter
    filterCat string // ""=全部, 否则: 视频/音乐/压缩/镜像/程序/文档/图片
}

func (mw *mainWindow) showContextFor(id int) {
    if id < 0 || id >= len(mw.view) { return }
    t := mw.view[id]
    btnStart := widget.NewButton(i18n.T("context.start"), func() { _ = mw.mgr.Start(t.ID, func(*manager.Task) { mw.list.Refresh() }) })
    btnPause := widget.NewButton(i18n.T("context.pause"), func() { _ = mw.mgr.Pause(t.ID) })
    btnPrUp := widget.NewButton(i18n.T("context.prio_up"), func() {
        np := int(t.Priority) - 1
        if np < int(manager.PriorityHigh) { np = int(manager.PriorityHigh) }
        if np != int(t.Priority) {
            _ = mw.mgr.UpdatePriority(t.ID, manager.Priority(np))
            mw.tasks = mw.mgr.List(); mw.applyView(); mw.list.Refresh()
        }
    })
    btnPrDown := widget.NewButton(i18n.T("context.prio_down"), func() {
        np := int(t.Priority) + 1
        if np > int(manager.PriorityLow) { np = int(manager.PriorityLow) }
        if np != int(t.Priority) {
            _ = mw.mgr.UpdatePriority(t.ID, manager.Priority(np))
            mw.tasks = mw.mgr.List(); mw.applyView(); mw.list.Refresh()
        }
    })
    btnOpenFile := widget.NewButton(i18n.T("context.open_file"), func() { mw.openSelectedFile() })
    btnOpen := widget.NewButton(i18n.T("context.open_dir"), func() { mw.openSelectedDir() })
    btnApplyRules := widget.NewButton(i18n.T("context.apply_rules"), func(){ _ = mw.mgr.ReapplyRules(t.ID); mw.tasks = mw.mgr.List(); mw.applyView(); mw.list.Refresh() })
    btnDetails := widget.NewButton(i18n.T("details.title"), func() { mw.showTaskDetails(t) })
    btnSchedule := widget.NewButton(i18n.T("context.schedule"), func() {
        entry := widget.NewEntry()
        entry.SetPlaceHolder(i18n.T("schedule.field.placeholder"))
        if !t.StartAt.IsZero() { entry.SetText(t.StartAt.Format("2006-01-02 15:04")) }
        dialog.ShowForm(i18n.T("schedule.title"), i18n.T("schedule.apply"), i18n.T("schedule.cancel"),
            []*widget.FormItem{widget.NewFormItem(i18n.T("field.start_time"), entry)}, func(ok bool) {
                if !ok { return }
                s := strings.TrimSpace(entry.Text)
                var at time.Time
                var e error
                if s != "" {
                    at, e = time.ParseInLocation("2006-01-02 15:04", s, time.Local)
                    if e != nil { dialog.ShowError(errors.New(i18n.T("error.schedule.format")), mw.win); return }
                }
                if err := mw.mgr.SetStartAt(t.ID, at); err != nil { dialog.ShowError(err, mw.win); return }
                mw.tasks = mw.mgr.List(); mw.applyView(); mw.list.Refresh()
            }, mw.win)
    })
    btnDelete := widget.NewButtonWithIcon(i18n.T("context.delete"), theme.DeleteIcon(), func() {
        _ = mw.mgr.Remove(t.ID); mw.tasks = mw.mgr.List(); mw.applyView(); mw.list.Refresh()
    })
    content := container.NewVBox(btnStart, btnPause, btnPrUp, btnPrDown, btnOpenFile, btnOpen, btnApplyRules, btnDetails, btnSchedule, btnDelete)
    dlg := dialog.NewCustom(i18n.T("context.title"), i18n.T("context.close"), content, mw.win)
    dlg.Show()
}

// showTaskDetails displays current source, all sources and parts completed (from meta file if present)
func (mw *mainWindow) showTaskDetails(t *manager.Task) {
    // current source
    curSrc := strings.TrimSpace(t.Progress.Source)
    if curSrc == "" { curSrc = "-" }
    proto := "HTTP"
    if isBTURL(t.URL) { proto = "BT" }
    peers := "-"
    if proto == "BT" {
        if t.Progress.Peers > 0 { peers = fmt.Sprintf("%d", t.Progress.Peers) } else { peers = "0" }
    }
    // all sources: primary + mirrors
    all := []string{}
    if strings.TrimSpace(t.URL) != "" { all = append(all, t.URL) }
    for _, s := range t.Sources { if strings.TrimSpace(s) != "" { all = append(all, s) } }
    srcList := strings.Join(all, "\n")
    // parts completed from meta
    metaPath := t.SavePath + ".meta.json"
    partsCompleted := "-"
    failedParts := "-"
    if b, err := os.ReadFile(metaPath); err == nil {
        type partMeta struct{ Start int64 `json:"start"`; End int64 `json:"end"`; Done bool `json:"done"` }
        type metaFile struct{ Total int64 `json:"total"`; Parts []partMeta `json:"parts"` }
        var mf metaFile
        if e := json.Unmarshal(b, &mf); e == nil && len(mf.Parts) > 0 {
            done := 0
            notDone := 0
            for _, p := range mf.Parts { if p.Done { done++ } }
            for _, p := range mf.Parts { if !p.Done { notDone++ } }
            partsCompleted = fmt.Sprintf("%d/%d", done, len(mf.Parts))
            failedParts = fmt.Sprintf("%d", notDone)
        }
    }
    // editable checksum fields
    selAlgo := widget.NewSelect([]string{"", "MD5", "SHA256"}, nil)
    selAlgo.SetSelected(strings.ToUpper(strings.TrimSpace(t.ChecksumAlgo)))
    entChecksum := widget.NewEntry()
    entChecksum.SetText(strings.TrimSpace(t.ChecksumValue))

    // sources list (readonly)
    srcLabel := widget.NewLabel(i18n.T("details.sources"))
    srcEntry := widget.NewMultiLineEntry()
    srcEntry.SetText(srcList)
    srcEntry.Disable()

    content := container.NewVBox(
        widget.NewForm(
            widget.NewFormItem("协议", widget.NewLabel(proto)),
            widget.NewFormItem(i18n.T("details.current_source"), widget.NewLabel(curSrc)),
            widget.NewFormItem("BT Peers", widget.NewLabel(peers)),
            widget.NewFormItem(i18n.T("details.parts_completed"), widget.NewLabel(partsCompleted)),
            widget.NewFormItem(i18n.T("details.failed_parts"), widget.NewLabel(failedParts)),
            widget.NewFormItem(i18n.T("details.retries"), widget.NewLabel(fmt.Sprintf("%d", t.Attempts))),
            widget.NewFormItem(i18n.T("sort.status"), widget.NewLabel(fmt.Sprintf("%d", t.Switches))),
            widget.NewFormItem(i18n.T("field.checksum_algo"), selAlgo),
            widget.NewFormItem(i18n.T("field.checksum_value"), entChecksum),
        ),
        srcLabel,
        srcEntry,
    )

    dialog.NewCustomConfirm(i18n.T("details.title"), i18n.T("action.ok"), i18n.T("action.cancel"), content, func(ok bool){
        if !ok { return }
        algo := strings.ToUpper(strings.TrimSpace(selAlgo.Selected))
        chk := strings.TrimSpace(entChecksum.Text)
        if algo != "" {
            if chk == "" { dialog.ShowError(fmt.Errorf(i18n.T("error.checksum.format")), mw.win); return }
            isHex := regexp.MustCompile(`^[0-9a-fA-F]+$`).MatchString
            if !isHex(chk) { dialog.ShowError(fmt.Errorf(i18n.T("error.checksum.format")), mw.win); return }
            if (algo == "MD5" && len(chk) != 32) || (algo == "SHA256" && len(chk) != 64) {
                dialog.ShowError(fmt.Errorf(i18n.T("error.checksum.format")), mw.win); return
            }
        }
        _ = mw.mgr.SetChecksum(t.ID, algo, chk)
    }, mw.win).Show()
}

func (mw *mainWindow) build() {
    mw.tasks = mw.mgr.List()
    mw.applyView()
    mw.lastStatus = make(map[string]manager.Status, 32)
    mw.status = widget.NewLabel(i18n.T("status.ready"))
    mw.clipWatch = true
    if mw.cfg.HttpAPIEnabled { _ = mw.startHTTPAPI() }

    // Filters: search / status / sort
    search := widget.NewEntry()
    search.SetPlaceHolder(i18n.T("filter.search.placeholder"))
    search.OnChanged = func(s string) { mw.filterText = strings.TrimSpace(s); mw.applyView(); if mw.list != nil { mw.list.Refresh() } }
    statusSel := widget.NewSelect([]string{
        i18n.T("filter.status.all"),
        i18n.T("filter.status.running"),
        i18n.T("filter.status.waiting"),
        i18n.T("filter.status.paused"),
        i18n.T("filter.status.completed"),
        i18n.T("filter.status.failed"),
    }, func(v string) {
        mw.filterStatus = nil
        mapping := map[string]manager.Status{
            i18n.T("filter.status.running"):   manager.StatusRunning,
            i18n.T("filter.status.waiting"):   manager.StatusWaiting,
            i18n.T("filter.status.paused"):    manager.StatusPaused,
            i18n.T("filter.status.completed"): manager.StatusCompleted,
            i18n.T("filter.status.failed"):    manager.StatusFailed,
        }
        if st, ok := mapping[v]; ok { mw.filterStatus = &st }
        mw.applyView(); if mw.list != nil { mw.list.Refresh() }
    })
    statusSel.SetSelected(i18n.T("filter.status.all"))
    catSel := widget.NewSelect([]string{
        i18n.T("filter.category.all"),
        i18n.T("filter.category.video"),
        i18n.T("filter.category.music"),
        i18n.T("filter.category.archive"),
        i18n.T("filter.category.iso"),
        i18n.T("filter.category.program"),
        i18n.T("filter.category.document"),
        i18n.T("filter.category.image"),
    }, func(v string){
        if v == i18n.T("filter.category.all") { mw.filterCat = "" } else { mw.filterCat = v }
        mw.applyView(); if mw.list != nil { mw.list.Refresh() }
    })
    catSel.SetSelected(i18n.T("filter.category.all"))
    sortSel := widget.NewSelect([]string{
        i18n.T("sort.time.new_first"),
        i18n.T("sort.time.old_first"),
        i18n.T("sort.priority"),
        i18n.T("sort.name"),
        i18n.T("sort.status"),
    }, func(v string) {
        switch v {
        case i18n.T("sort.time.new_first"): mw.sortKey, mw.sortAsc = "created", false
        case i18n.T("sort.time.old_first"): mw.sortKey, mw.sortAsc = "created", true
        case i18n.T("sort.priority"): mw.sortKey, mw.sortAsc = "priority", true
        case i18n.T("sort.name"): mw.sortKey, mw.sortAsc = "name", true
        case i18n.T("sort.status"): mw.sortKey, mw.sortAsc = "status", true
        }
        mw.applyView(); if mw.list != nil { mw.list.Refresh() }
    })
    sortSel.SetSelected(i18n.T("sort.time.new_first"))

    // Header buttons
    mw.hdrName = widget.NewButton("名称", func(){ mw.toggleSort("name") })
    mw.hdrStatus = widget.NewButton("状态", func(){ mw.toggleSort("status") })
    mw.hdrProg = widget.NewButton("进度", func(){ mw.toggleSort("progress") })
    mw.hdrSpeed = widget.NewButton("速度", func(){ mw.toggleSort("speed") })
    mw.hdrPrio = widget.NewButton("优先级", func(){ mw.toggleSort("priority") })
    mw.hdrTime = widget.NewButton("时间", func(){ mw.toggleSort("created") })
    header := container.NewGridWithColumns(6,
        mw.hdrName, mw.hdrStatus, mw.hdrProg, mw.hdrSpeed, mw.hdrPrio, mw.hdrTime,
    )
    mw.updateHeader()

    mw.list = widget.NewList(
        func() int { return len(mw.view) },
        func() fyne.CanvasObject {
            name := widget.NewLabel("name")
            status := widget.NewLabel("status")
            progress := widget.NewProgressBar()
            inner := container.NewVBox(
                container.NewGridWithColumns(2, name, status),
                progress,
            )
            return newRowBox(inner)
        },
        func(id widget.ListItemID, co fyne.CanvasObject) {
            rb := co.(*rowBox)
            row := rb.content.(*fyne.Container)
            // inner row.Objects: [Grid( name, status ), ProgressBar]
            grid := row.Objects[0].(*fyne.Container)
            name := grid.Objects[0].(*widget.Label)
            status := grid.Objects[1].(*widget.Label)
            progress := row.Objects[1].(*widget.ProgressBar)
            t := mw.view[id]
            prefix := ""
            if isBTURL(t.URL) { prefix = "[BT] " }
            name.SetText(prefix + priorityLabel(t.Priority) + " " + filepath.Base(t.SavePath))
            status.SetText(renderStatus(t))
            if t.Progress.Total > 0 {
                progress.Max = float64(t.Progress.Total)
                progress.SetValue(float64(t.Progress.Completed))
            } else {
                progress.Max = 1
                progress.SetValue(0)
            }
            rb.onRight = func(pos fyne.Position) {
                mw.selected = id
                mw.showContextFor(id)
            }
            rb.onTap = func() {
                mw.selected = id
                // toggle start/pause
                cur := mw.view[id]
                if cur.Status == manager.StatusRunning {
                    _ = mw.mgr.Pause(cur.ID)
                } else {
                    _ = mw.mgr.Start(cur.ID, func(task *manager.Task) { mw.list.Refresh() })
                }
                mw.tasks = mw.mgr.List()
                mw.applyView()
                mw.list.Refresh()
            }
        },
    )

    mw.selected = -1
    mw.list.OnSelected = func(id widget.ListItemID) { mw.selected = id }
    mw.list.OnUnselected = func(id widget.ListItemID) { if mw.selected == id { mw.selected = -1 } }

    toolbar := widget.NewToolbar(
        widget.NewToolbarAction(theme.ContentAddIcon(), func() { mw.addTask() }),
        widget.NewToolbarAction(theme.DocumentCreateIcon(), func() { mw.addBatch() }),
        widget.NewToolbarAction(theme.MediaPlayIcon(), func() { mw.startSelected() }),
        widget.NewToolbarAction(theme.MediaPauseIcon(), func() { mw.pauseSelected() }),
        widget.NewToolbarAction(theme.FolderOpenIcon(), func() { mw.openSelectedDir() }),
        widget.NewToolbarAction(theme.DeleteIcon(), func() { mw.removeSelected() }),
        widget.NewToolbarAction(theme.SettingsIcon(), func() { mw.openSettings() }),
    )

    menu := fyne.NewMainMenu(
        fyne.NewMenu(i18n.T("menu.file"),
            fyne.NewMenuItem(i18n.T("menu.add"), func() { mw.addTask() }),
            fyne.NewMenuItem(i18n.T("menu.import"), func() { mw.importTasks() }),
            fyne.NewMenuItem(i18n.T("menu.export"), func() { mw.exportTasks() }),
            fyne.NewMenuItem(i18n.T("menu.start_all"), func() { mw.startAll() }),
            fyne.NewMenuItem(i18n.T("menu.pause_all"), func() { mw.pauseAll() }),
            fyne.NewMenuItemSeparator(),
            fyne.NewMenuItem(i18n.T("menu.clear_completed"), func() { mw.clearCompleted() }),
            fyne.NewMenuItemSeparator(),
            fyne.NewMenuItem(i18n.T("menu.quit"), func() { fyne.CurrentApp().Quit() }),
        ),
        fyne.NewMenu(i18n.T("menu.edit")),
    )
    mw.win.SetMainMenu(menu)

    bottomBar := container.NewHBox(
        mw.status,
        widget.NewButton(i18n.T("button.speed"), func() { mw.openSpeedLimit() }),
    )
    filters := container.NewHBox(search, statusSel, catSel, sortSel)
    content := container.NewBorder(container.NewVBox(toolbar, header, filters), bottomBar, nil, nil, mw.list)
    mw.win.SetContent(content)

    // Intercept close: hide to background and notify
    mw.win.SetCloseIntercept(func() {
        mw.win.Hide()
        fyne.CurrentApp().SendNotification(&fyne.Notification{
            Title:   i18n.T("notify.bg.title"),
            Content: i18n.T("notify.bg.content"),
        })
    })

    go func() {
        for range time.Tick(500 * time.Millisecond) {
            mw.tasks = mw.mgr.List()
            mw.applyView()
            mw.list.Refresh()
            // global stats
            var running, waiting, completed int
            var totalSpeed int64
            for _, t := range mw.tasks {
                switch t.Status {
                case manager.StatusRunning:
                    running++
                case manager.StatusWaiting:
                    waiting++
                case manager.StatusCompleted:
                    completed++
                }
                totalSpeed += t.Progress.SpeedBps
                // notify on status change
                prev, ok := mw.lastStatus[t.ID]
                if !ok || prev != t.Status {
                    mw.lastStatus[t.ID] = t.Status
                    if t.Status == manager.StatusCompleted {
                        fyne.CurrentApp().SendNotification(&fyne.Notification{
                            Title:   "下载完成",
                            Content: filepath.Base(t.SavePath),
                        })
                        // auto open behavior
                        if mw.cfg.AutoOpenFile {
                            mw.openFilePath(t.SavePath)
                        } else if mw.cfg.AutoOpenFolder {
                            mw.openDirPath(filepath.Dir(t.SavePath))
                        }
                    } else if t.Status == manager.StatusFailed {
                        fyne.CurrentApp().SendNotification(&fyne.Notification{
                            Title:   "下载失败",
                            Content: filepath.Base(t.SavePath),
                        })
                    }
                }
            }
            spMB := float64(totalSpeed) / 1024.0 / 1024.0
            mw.status.SetText(fmt.Sprintf("运行: %d  等待: %d  完成: %d  | 总速度: %.2f MB/s", running, waiting, completed, spMB))
            mw.updateTray(running, waiting, completed, spMB)
            // auto-shutdown arming
            if mw.cfg.AutoShutdown && !mw.shutdownArmed && running == 0 && waiting == 0 && completed > 0 {
                mw.armShutdown(mw.cfg.ShutdownDelayMin)
            }
        }
    }()

    // Clipboard monitor
    go func(){
        urlRe := regexp.MustCompile(`(?i)^(https?|ftp)://[\w\-._~:/?#\[\]@!$&'()*+,;=%]+$`)
        for range time.Tick(1200 * time.Millisecond) {
            if !mw.clipWatch { continue }
            cb := mw.win.Clipboard()
            if cb == nil { continue }
            txt := strings.TrimSpace(cb.Content())
            if txt == "" || txt == mw.lastClip { continue }
            if !urlRe.MatchString(txt) { continue }
            mw.lastClip = txt
            // prompt quick add on UI thread
            u := txt
            fyne.CurrentApp().SendNotification(&fyne.Notification{ Title: "检测到链接", Content: "点击确认添加到下载" })
            mw.promptQuickAdd(u)
        }
    }()

    mw.win.Resize(fyne.NewSize(900, 600))
}

func (mw *mainWindow) startSelected() {
    id := mw.selected
    if id < 0 || id >= len(mw.view) {
        return
    }
    _ = mw.mgr.Start(mw.view[id].ID, func(task *manager.Task) { mw.list.Refresh() })
}

func (mw *mainWindow) pauseSelected() {
    id := mw.selected
    if id < 0 || id >= len(mw.view) { return }
    _ = mw.mgr.Pause(mw.view[id].ID)
}

func (mw *mainWindow) removeSelected() {
    id := mw.selected
    if id < 0 || id >= len(mw.view) {
        return
    }
    t := mw.view[id]
    dialog.NewConfirm(i18n.T("confirm.remove.title"), fmt.Sprintf(i18n.T("confirm.remove.message"), filepath.Base(t.SavePath)), func(ok bool) {
        if !ok {
            return
        }
        _ = mw.mgr.Remove(t.ID)
        mw.tasks = mw.mgr.List()
        mw.applyView()
        mw.list.Refresh()
    }, mw.win).Show()
}

func (mw *mainWindow) openSelectedDir() {
    id := mw.selected
    if id < 0 || id >= len(mw.view) { return }
    dir := filepath.Dir(mw.view[id].SavePath)
    mw.openDirPath(dir)
}

func (mw *mainWindow) moreActions() {
    idx := mw.selected
    if idx < 0 || idx >= len(mw.view) { return }
    t := mw.view[idx]
    prSelect := widget.NewSelect([]string{i18n.T("priority.high"), i18n.T("priority.medium"), i18n.T("priority.low")}, nil)
    switch t.Priority {
    case manager.PriorityHigh:
        prSelect.SetSelected(i18n.T("priority.high"))
    case manager.PriorityLow:
        prSelect.SetSelected(i18n.T("priority.low"))
    default:
        prSelect.SetSelected(i18n.T("priority.medium"))
    }
    content := container.NewVBox(
        widget.NewLabel(i18n.T("label.modify_priority")),
        prSelect,
    )
    dialog.NewCustomConfirm(i18n.T("more.title"), i18n.T("more.apply"), i18n.T("more.cancel"), content, func(ok bool) {
        if !ok { return }
        var pr manager.Priority
        switch prSelect.Selected {
        case i18n.T("priority.high"): pr = manager.PriorityHigh
        case i18n.T("priority.low"): pr = manager.PriorityLow
        default: pr = manager.PriorityMedium
        }
        _ = mw.mgr.UpdatePriority(t.ID, pr)
        mw.tasks = mw.mgr.List()
        mw.applyView()
        mw.list.Refresh()
    }, mw.win).Show()
}

func renderStatus(t *manager.Task) string {
    switch t.Status {
    case manager.StatusWaiting:
        if !t.StartAt.IsZero() && time.Now().Before(t.StartAt) {
            // show short time if same day
            if time.Now().Format("2006-01-02") == t.StartAt.Format("2006-01-02") {
                return i18n.TF("status.scheduled.at", t.StartAt.Format("15:04"))
            }
            return i18n.TF("status.scheduled.at", t.StartAt.Format("01-02 15:04"))
        }
        return i18n.T("status.waiting")
    case manager.StatusRunning:
        spMB := float64(t.Progress.SpeedBps) / 1024 / 1024
        if t.Progress.Total > 0 {
            pct := float64(t.Progress.Completed) * 100 / float64(t.Progress.Total)
            doneMB := float64(t.Progress.Completed) / 1024 / 1024
            totalMB := float64(t.Progress.Total) / 1024 / 1024
            var eta string
            if t.Progress.SpeedBps > 0 {
                remain := float64(t.Progress.Total-t.Progress.Completed) / float64(t.Progress.SpeedBps)
                if remain >= 3600 {
                    eta = fmt.Sprintf("~%.0fh", remain/3600)
                } else if remain >= 60 {
                    eta = fmt.Sprintf("~%.0fm", remain/60)
                } else {
                    eta = fmt.Sprintf("~%.0fs", remain)
                }
            } else {
                eta = "--"
            }
            s := i18n.TF("status.running.known", pct, doneMB, totalMB, spMB, eta)
            if strings.TrimSpace(t.Progress.Source) != "" {
                s += " | " + i18n.TF("status.source", t.Progress.Source)
            }
            return s
        }
        s := i18n.TF("status.running.unknown", spMB)
        if strings.TrimSpace(t.Progress.Source) != "" {
            s += " | " + i18n.TF("status.source", t.Progress.Source)
        }
        return s
    case manager.StatusPaused:
        return i18n.T("status.paused")
    case manager.StatusCompleted:
        return i18n.T("status.completed")
    case manager.StatusFailed:
        return i18n.TF("status.failed", t.Err)
    default:
        return i18n.T("status.unknown")
    }
}
